Skip to content

Vendor MetadataKeyStore: debounced single-key sidecar writer#508

Merged
bdraco merged 3 commits into
mainfrom
vendor-metadata-store-helper
May 9, 2026
Merged

Vendor MetadataKeyStore: debounced single-key sidecar writer#508
bdraco merged 3 commits into
mainfrom
vendor-metadata-store-helper

Conversation

@bdraco

@bdraco bdraco commented May 9, 2026

Copy link
Copy Markdown
Member

What does this implement/fix?

Vendors a small helpers/metadata_store.MetadataKeyStore modelled on
Home Assistant's homeassistant.helpers.storage.Store for the
"all state in RAM, write debounced to disk" pattern. The intent is
to land the helper first so the upcoming receiver / offloader
pairings RAM-only refactors can build on a reviewed primitive (and
so a future audit can flip _devices, _labels, _prefs, etc. onto
it incrementally). No consumers wired up in this PR; MetadataKeyStore
imports cleanly and ships at 100% coverage.

The store doesn't reach into our shared metadata sidecar directly —
callers inject load_sync / write_sync so the heavy lifting
stays in controllers.config (which already owns the lock + atomic
write).

Drops every HA dep we don't need: bus events
(EVENT_HOMEASSISTANT_FINAL_WRITE), _StoreManager preload cache,
version migration, _load_future reentrancy guard. Keeps the parts
that earn complexity:

  • Debounce + extend semantics matching HA bit-for-bit — calls
    during an open delay window update the latest deadline; the timer
    reschedules itself when it wakes early.
  • Lock-protected single-flight writes so a stop()-triggered
    final flush can't race a delayed-handler-driven write.
  • data_func captured at every schedule, invoked at flush time,
    so the persisted snapshot reflects the controller's latest
    in-RAM state instead of stale data from when the first call
    queued the handle.

Adds hass to the codespell ignore list (the docstring references
HA's storage module by path).

Related issue or feature (if applicable):

  • Prep for the offloader-side pairings RAM-only refactor; consumer
    PR follows once this lands.

Types of changes

  • Bugfix (non-breaking change which fixes an issue) — bugfix
  • New feature (non-breaking change which adds functionality) — new-feature
  • Enhancement to an existing feature — enhancement
  • Breaking change (fix or feature that would cause existing functionality to not work as expected) — breaking-change
  • Refactor (no behaviour change) — refactor
  • Documentation only — docs
  • Maintenance / chore — maintenance
  • CI / workflow change — ci
  • Dependencies bump — dependencies

Frontend coordination

  • No frontend change needed
  • Companion frontend PR: esphome/device-builder-dashboard-frontend#

Checklist

  • The code change is tested and works locally.
  • Pre-commit hooks pass (ruff, codespell, yaml/json/python checks).
  • Tests have been added or updated under tests/ where applicable.
  • components.json has not been hand-edited (regenerate via script/sync_components.py if a sync is needed).
  • Architecture-level changes are reflected in docs/ARCHITECTURE.md and/or docs/API.md.

Adds a small ``helpers/metadata_store.MetadataKeyStore`` modelled
on Home Assistant's ``homeassistant.helpers.storage.Store`` for the
"all state in RAM, write debounced to disk" pattern. Drops every HA
dep we don't need (bus events, _StoreManager preload cache, version
migration, _load_future reentrancy guard); keeps the parts that
earn complexity:

* Debounce + extend semantics matching HA bit-for-bit — calls
  during an open delay window update the latest deadline; the timer
  reschedules itself when it wakes early.
* Lock-protected single-flight writes so a stop()-triggered final
  flush can't race a delayed-handler-driven write.
* data_func captured at every schedule, invoked at flush time, so
  the persisted snapshot reflects the controller's latest in-RAM
  state instead of stale data from when the first call queued the
  handle.

The store doesn't reach into our shared metadata sidecar directly;
callers inject ``load_sync`` / ``write_sync`` so the heavy lifting
stays in ``controllers.config`` (which owns the lock + atomic
write). No consumers wired up here — landing the helper first so
the upcoming receiver / offloader pairings RAM-only refactors can
build on a reviewed primitive.

Adds ``hass`` to the codespell ignore list (the docstring
references HA's storage module).
Copilot AI review requested due to automatic review settings May 9, 2026 21:54
@github-actions github-actions Bot added the new-feature New feature label May 9, 2026
@codspeed-hq

codspeed-hq Bot commented May 9, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 12 untouched benchmarks


Comparing vendor-metadata-store-helper (244e0eb) with main (f74602b)

Open in CodSpeed

@codecov-commenter

codecov-commenter commented May 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.14%. Comparing base (f74602b) to head (244e0eb).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main     #508   +/-   ##
=======================================
  Coverage   99.13%   99.14%           
=======================================
  Files          67       68    +1     
  Lines        8007     8070   +63     
=======================================
+ Hits         7938     8001   +63     
  Misses         69       69           
Flag Coverage Δ
py3.12 99.10% <100.00%> (+<0.01%) ⬆️
py3.14 99.14% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
esphome_device_builder/helpers/metadata_store.py 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR vendors a new MetadataKeyStore helper that implements the “keep state in RAM, debounce writes to disk” pattern for a single sub-key of the shared metadata sidecar, matching Home Assistant’s debounce/extend semantics while delegating actual I/O (and locking/atomicity) to injected callbacks.

Changes:

  • Added esphome_device_builder.helpers.metadata_store.MetadataKeyStore implementing debounced, single-flight async writes via executor + asyncio.Lock.
  • Added a comprehensive async test suite covering debounce collapsing, deadline extension, flush semantics, and write-failure swallowing.
  • Updated codespell ignore list to include hass (used in docstrings referencing Home Assistant).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
esphome_device_builder/helpers/metadata_store.py Introduces the debounced single-key metadata store helper with HA-matching scheduling semantics.
tests/test_helpers_metadata_store.py Adds unit tests validating scheduling/flush behavior, inflight-write coordination, and error logging.
pyproject.toml Extends codespell ignore list to allow hass in documentation strings.

Comment thread esphome_device_builder/helpers/metadata_store.py Outdated
Comment thread esphome_device_builder/helpers/metadata_store.py Outdated
bdraco added 2 commits May 9, 2026 17:01
Promotes the shutdown-flush wiring from "remember to call
async_save_now() in stop()" to a structural contract: the
constructor now requires a shutdown_register callback, invoked
exactly once with self.async_save_now during __init__. The caller's
lifecycle layer (typically DeviceBuilder.shutdown_callbacks) holds
the resulting list and awaits every callback at graceful stop.

A store can no longer be instantiated without telling someone who
will flush it; tests that genuinely don't care can pass
``lambda _cb: None`` to opt out, but production paths must wire a
real registry. The hard-kill caveat (SIGKILL / process crash) is
unchanged from HA's Store and documented inline.

Adds ShutdownCallback / ShutdownRegister PEP 695 type aliases so
call sites don't have to spell the nested Callable inline, and a
test exercising the cross-store lifecycle drain (multiple stores
sharing one registry, one walk flushes them all).
Two real catches:

* Class docstring referenced ``*_T*`` but the PEP-695 type
  parameter is ``T``; renamed the docstring callout so a future
  reader doesn't trip over the mismatch.
* Error log line ``"Error writing metadata key"`` carried no
  identifying context — a production failure trace couldn't tell
  *which* sub-key broke. Adds an optional ``name`` constructor arg
  (typically the sidecar sub-key, e.g.
  ``"_offloader_remote_build"``) that surfaces in both the asyncio
  task name and the error log line, plus the ``config_dir`` for
  good measure. Defaults to ``"<unnamed:{config_dir}>"`` so a
  caller that omits the arg still gets *something* identifying.

New tests pin the diagnostic shape (name + config_dir present in
the log line) and the default-name fallback.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new-feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants